昨天我們聊了 Thread 和 Process,留下了一個大問題:為什麼 Python 的多執行緒沒辦法好好利用多核心 CPU 來跑運算密集的任務呢?答案就是今天的主角——GIL (Global Interpreter Lock),翻成中文叫「全域直譯器鎖」。
GIL 是 CPython(就是我們平常用的那個 Python 直譯器)裡一個超重要的機制,也是被大家討論最多、最有爭議的設計。想要真正搞懂 Python 的並行程式設計,一定要先理解 GIL 是怎麼運作的。
註: 值得一提的是,自 Python 3.13 版本起 (基於 PEP 703),CPython 提供了停用 GIL 的實驗性支援,這對 Python 社群是個重大的發展。然而,這篇文章的討論仍聚焦於 GIL 預設啟用的傳統行為上,這些觀念和策略在絕大多數的現行專案中依然至關重要。因此,這篇文章並不會探討新的
nogil
模式。
簡單來說,GIL 就是一把「全域鎖」。
我們來打個比方:把 Python 直譯器想像成一間工廠,有很多 CPU 核心(工人),也有很多執行緒(要做的任務)。GIL 就像是工廠裡唯一的一把「萬能鑰匙」。規則很簡單:不管什麼時候,只有拿到這把鑰匙的工人(執行緒),才能進入工廠的核心車間(執行 Python bytecode)做事。
這代表什麼意思呢?就算你的電腦有 8 個 CPU 核心,在跑 CPython 多執行緒程式的時候,同一個時間點還是只有 1 個核心上的 1 個執行緒在真正執行 Python 程式碼。其他執行緒就算被分配到別的 CPU 核心上,也只能在那邊乾等著,等那把「萬能鑰匙」被放出來。
你可能會想:「這設計也太奇怪了吧?為什麼要這樣限制?」其實 GIL 的存在有它的道理:
Python 的記憶體管理是靠「引用計數」(Reference Counting) 這個機制在運作的。簡單說,就是每個物件都會記錄有多少個地方在用它,當沒人用的時候就自動回收。
但是!如果沒有 GIL,多個執行緒同時去修改同一個物件的引用計數,就會出大問題——可能導致記憶體洩漏,或是提早把還在用的記憶體給回收掉。GIL 保證了這種操作的安全性,讓開發者不用寫一堆複雜的鎖來保護每個物件。
Python 生態圈有超多用 C 寫的擴充模組(像是 NumPy、Pandas 這些)。有了 GIL,這些 C 擴充的開發者就不用煩惱複雜的執行緒安全問題,大大降低了開發門檻。這也是為什麼 Python 生態系能發展得這麼蓬勃的原因之一。
在單執行緒環境下,GIL 實際上提升了效能,因為它避免了頻繁的鎖操作開銷。這使得 Python 在單執行緒應用中表現優異。
GIL 的影響其實要看你在做什麼類型的任務:
對於 CPU 密集型任務:影響超級大!用多執行緒來跑這種需要大量運算的任務,不但不會變快,反而可能因為執行緒之間搶 GIL 的額外開銷而變得更慢。
對於 I/O 密集型任務:影響其實不大。為什麼?因為當 Python 執行緒在做網路請求、讀寫檔案這些 I/O 操作時,它會很貼心地主動把 GIL 釋放出來,讓其他在等待的執行緒有機會上場。所以像 FastAPI 這種網路應用,用多執行緒來處理 I/O 請求還是很有效的。
既然 GIL 是 CPython 的一部分,那我們該如何處理 CPU 密集型任務?
用 multiprocessing
:這是最推薦的做法。就像昨天說的,每個程序都有自己獨立的 Python 直譯器和記憶體空間,當然也有自己的 GIL,大家互不干擾,可以完美發揮多核心 CPU 的威力。
換個直譯器:像是 Jython(Java 版的 Python)或 IronPython(.NET 版的 Python)就沒有 GIL 的問題。不過,這些直譯器的社群支援和套件生態都遠遠比不上 CPython,所以實用性有限。
用 C 擴充套件:如果有超級吃 CPU 的運算,可以把那部分用 C/C++ 或 Rust 寫成 Python 模組,並在 C 的層級手動釋放 GIL,這樣就能實現真正的平行運算。NumPy 就是這樣做的,這也是它跑得超快的原因XD
GIL 限制了 CPython 多執行緒的平行運算能力,但對 I/O 密集型任務的影響不大。面對不同情況,要懂得選擇用 asyncio
、threading
還是 multiprocessing
,這是每個 Python 開發者都該學會的基本功。
接下來,我們要回到 FastAPI 的世界啦!下一篇會正式比較 def
和 async def
在路由函式中的表現和選擇時機,把這幾天學到的理論知識和實際框架結合起來~